[Java] 03. 채널 핸들러 라이프사이클과 실전 IoT 소켓 통신 패턴

NOTE

Netty의 ChannelInboundHandlerAdapter 수명 주기, 다이렉트 버퍼 메모리 누수 방지 기법(ReferenceCountUtil), BCD 날짜 포맷 파싱, L4 환경에서의 HAProxy PROXY Protocol v1/v2 실제 IP 복원 파서 구현 원리를 정리합니다. (고성능 소켓 통신 서버 아키텍처)

실행 환경

ChannelInboundHandlerAdapter, ReferenceCountUtil, io.netty.util.* API 형태로 볼 때 Netty 4.x 계열로 추정됩니다(정확한 패치 버전 명시 없음). 코드에 var/레코드/패턴 매칭 등 JDK 버전을 특정할 문법적 단서가 없어 JDK 버전은 명시되어 있지 않습니다.


1. ChannelInboundHandler 라이프사이클

클라이언트 소켓이 서버에 연결되어 데이터를 주고받고 종료될 때까지 호출되는 콜백 체인입니다.

flowchart TD
    A["channelRegistered() : 이벤트루프에 채널 등록"] --> B["channelActive() : TCP 연결 완료 (입출력 가능)"]
    B --> C["channelRead() : 메시지 수신 (디코더 통과 후)"]
    C --> D["channelReadComplete() : 읽기 완료"]
    D --> E{"연결 지속 여부"}
    E -->|신규 패킷 도착| C
    E -->|소켓 끊김 감지| F["channelInactive() : TCP 연결 종료"]
    F --> G["handlerRemoved() : 채널맵 세션 제거 & DB 상태 업데이트"]
    
    C -.->|예외 발생 시| ERR["exceptionCaught() : 로그 기록 및 ctx.close()"]

2. 실전 MessageHandler와 메모리 관리

package com.example.netty.server.handler;
 
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.util.ReferenceCountUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
 
@Slf4j
@RequiredArgsConstructor
public class MessageHandler extends ChannelInboundHandlerAdapter {
 
    private final AppMapper appMapper;
 
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        try {
            ByteBuf byteBuf = (ByteBuf) msg;
            byte[] bytes = new byte[byteBuf.readableBytes()];
            byteBuf.getBytes(byteBuf.readerIndex(), bytes);
 
            // 비즈니스 처리 매니저 호출 (Strategy 패턴)
            ProcessManager processManager = TxApplication.context.getBean(ProcessManager.class);
            MessageEncoderDto encodeDto = processManager.doProcess(bytes, ctx);
 
            if (encodeDto != null) {
                ctx.writeAndFlush(encodeDto);
            }
        } catch (Exception e) {
            exceptionCaught(ctx, e);
        } finally {
            // 중요: 네티의 Direct Buffer 참조 카운트 감소 (메모리 누수 방지 필수)
            ReferenceCountUtil.release(msg);
        }
    }
 
    @Override
    public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {
        // 클라이언트(커피머신) 전원 꺼짐 또는 비정상 종료 시 세션 맵 정리 및 DB 상태 OFF 업데이트
        for (Map.Entry<String, ChannelInfo> entry : AppConfig.channelMap.entrySet()) {
            if (entry.getValue().getCtx().equals(ctx)) {
                Map<String, Object> param = new HashMap<>();
                param.put("mid", entry.getKey().substring(0, 10));
                param.put("devId", entry.getKey().substring(10, 18));
                param.put("devStatus", "0"); // 0: OFF, 1: ON
 
                appMapper.updateDevStatus(param);
                AppConfig.channelMap.remove(entry.getKey());
                log.info("Device disconnected and status set to OFF: {}", entry.getKey());
                break;
            }
        }
    }
 
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        log.error("[MessageHandler] Socket Exception: {}", cause.getMessage());
        ctx.close(); // 소켓 자원 누수 방지
    }
}

⚠️ ReferenceCountUtil.release(msg)의 중요성

  • Netty는 고성능 처리를 위해 JVM 힙이 아닌 **C 라이브러리 레벨의 직접 메모리(Direct Memory / Off-heap)**를 사용합니다.
  • Direct Buffer는 JVM 가비지 컬렉터(GC)의 관리 대상이 아니므로, 참조 카운트(Reference Count)가 0이 되어야 네이티브 메모리가 해제됩니다.
  • channelRead() 마지막에 finally { ReferenceCountUtil.release(msg); }를 명시하지 않으면 서버가 가동된 지 며칠 만에 OutOfMemoryError: Direct buffer memory가 발생하며 서버가 뻗게 됩니다.

3. BCD (Binary Coded Decimal) 날짜 처리와 Strategy 패턴

많은 산업용 장비 및 결제 단말기는 날짜 14자리를 문자열(“20260905213000”)로 보내지 않고, 1바이트에 2개의 10진수 숫자를 압축한 BCD 7바이트 포맷을 사용합니다.

// BCD to String 변환 (CommonUtil.java)
public static String bcdToString(byte[] bytes) {
    StringBuilder sb = new StringBuilder();
    for (byte b : bytes) {
        sb.append((b >> 4) & 0x0F); // 상위 4비트
        sb.append(b & 0x0F);        // 하위 4비트
    }
    return sb.toString(); // 예: 20 26 09 05 21 30 00 -> "20260905213000"
}

커맨드 코드 기반 동적 Bean 라우팅

수십 가지의 장비 제어 커맨드(1A, 1B, 1C…)를 거대한 if-else문으로 처리하지 않고, Spring ApplicationContext를 활용한 **전략 패턴(Strategy Pattern)**으로 처리합니다.

// ProcessManager.java
String insStr = getInsToString(bytes); // 28~29 오프셋에서 "1A", "1B" 추출
InsProcess insProcess = (InsProcess) TxApplication.context.getBean(insStr);
return insProcess.doProcess(msg, ctx);

4. L4 / HAProxy 환경에서의 PROXY Protocol v1/v2 파서 구현

서버 앞단에 L4 로드밸런서나 HAProxy가 위치할 경우, ctx.channel().remoteAddress()를 조회하면 장비의 실제 IP가 아닌 로드밸런서의 내부 사설 IP가 찍히게 됩니다.

프로토콜 맨 앞단에 ProxyDetector를 두어 PROXY 프로토콜 헤더를 감지하고 실제 클라이언트 IP를 추출하여 Channel.attr(REAL_IP)에 저장한 뒤 패킷을 정리합니다.

private class ProxyDetector extends ByteToMessageDecoder {
 
    // v2 시그니처 (12바이트 매직 헤더)
    private static final byte[] V2_MAGIC = new byte[]{0x0d, 0x0a, 0x0d, 0x0a, 0x00, 0x0d, 0x0a, 0x0a};
    private static final byte[] V1_PREFIX = "PROXY ".getBytes();
 
    @Override
    protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
        if (!in.isReadable()) return;
 
        int readerIndex = in.readerIndex();
        boolean isV2 = checkV2(in, readerIndex);
        boolean isV1 = !isV2 && checkV1(in, readerIndex);
 
        if (isV1) {
            // PROXY TCP4 123.45.67.89 ... \r\n 라인 파싱
            int lfIndex = findCRLF(in, readerIndex);
            if (lfIndex > 0) {
                int headerLen = lfIndex - readerIndex;
                byte[] headerBytes = new byte[headerLen];
                in.getBytes(readerIndex, headerBytes);
                String header = new String(headerBytes);
                String clientIp = header.trim().split("\\s+")[2];
                
                ctx.channel().attr(NettyServer.REAL_IP).set(clientIp);
                in.skipBytes(headerLen); // 헤더 제거 후 순수 데이터만 통과시킴
            }
        } else if (isV2) {
            // v2 바이너리 헤더 파싱 (16바이트 고정 + IPv4 4바이트 추출)
            int lenFieldOffset = readerIndex + 14;
            int length = ((in.getByte(lenFieldOffset) & 0xFF) << 8) | (in.getByte(lenFieldOffset + 1) & 0xFF);
            int totalHeaderLen = 16 + length;
            
            byte[] addr = new byte[4];
            in.getBytes(readerIndex + 16, addr);
            String clientIp = (addr[0] & 0xFF) + "." + (addr[1] & 0xFF) + "." + (addr[2] & 0xFF) + "." + (addr[3] & 0xFF);
            
            ctx.channel().attr(NettyServer.REAL_IP).set(clientIp);
            in.skipBytes(totalHeaderLen);
        }
 
        // 프록시 헤더 처리가 끝나면 본 핸들러를 파이프라인에서 제거하여 이후 패킷 오버헤드 방지
        ctx.pipeline().remove(this);
    }
}
  • 첫 패킷에서 프록시 헤더를 소모한 뒤 ctx.pipeline().remove(this)로 파이프라인에서 자기 자신을 동적으로 제거하므로, 이후 지속되는 소켓 통신에서는 제로 오버헤드로 순수 비즈니스 패킷만 처리됩니다.

관련 문서